SMOODEV-2820: th branding — white-label an org, logo included, from the CLI - #340
Merged
Conversation
…he CLI White-labeling a partner org meant clicking through the dashboard, and the logo step was the part that never really worked: the settings page takes a URL, so a partner's logo stayed hosted on the partner's own server. This adds `th branding` (alias `th brand`) as a top-level command — show / from-url / set / enable / disable / preview / clear — that treats the logo as bytes to re-host rather than a link to store. `--logo`, `--logo-dark` and `--favicon` each take a local path OR a remote URL. A remote one is fetched and uploaded to the org's brand assets, so what lands in the branding row is always a URL we serve. Three deliberate refusals: - `enable` (and `from-url --enable`) computes WCAG contrast for the three foreground/background pairs and stops under 4.5:1. Shipping an unreadable dashboard to a partner is the failure this gate exists for; `--force` overrides it loudly. The check runs BEFORE the write, not after. - Remote logo URLs are vetted like the server's `vetUrl` — http(s) only, no loopback / RFC1918 / 169.254 (cloud metadata), no redirect following, 5 MB cap — and the bytes are magic-byte sniffed against the platform allowlist before upload. `.ico` gets a named error instead of an opaque 400. - Every partial theme edit is a read-modify-write: the server's PUT replaces the whole `themeJson` column, so a naive `set --primary` would silently wipe every other token the partner had configured. `from-url` is a dry run by default — swatch table, logo candidates, contrast verdict, no writes. `--apply` stages (enabled stays false, previewable via `?brandPreview=1`); `--enable` goes live. Uploads go through `POST /organizations/:org_id/logo/upload`, the only endpoint that puts brand images in the PUBLIC-read published-assets bucket — the presigned `files/upload-url` flow writes to the private bucket whose URLs 403 wherever they render (pearl th-8f3591 / SMOODEV-2566). That endpoint also stamps `organizations.<variant>`, which is correct for logo and icon but has no dark slot; rather than borrow `logoWordmark` (it renders in live customer proposals) the dark upload borrows `logo` and puts the column back. The Aurora meaning tokens (heat / ai / gradients / ok-warn-crit) encode meaning, not chrome, and are deliberately unexposed. Two server-side gaps are surfaced as diagnoses instead of bare errors: the platform's white-label write validator is still Phase 1 so the surface tokens (--background, --card, --sidebar, ...) 400 today, and `from-url` 404s until the propose endpoint deploys. 28 unit tests: theme merge, WCAG ratios against reference values, the enable gate across server/local/force combinations, the frozen propose fixture, SSRF vetting, and image sniffing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dr6czSxJFJodJ9JkYiiBSp
🦋 Changeset detectedLatest commit: 851ac5c The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
… isn't a pass
Three corrections from Lane A's real contract and the live chakrasolutions.ai run:
- The propose endpoint OMITS a contrast ratio it couldn't derive, so
`passes: true` can arrive with zero numbers behind it — an empty proposal
trivially "passes". Painting a green check over nothing is exactly how an
unreadable theme would slip past the gate it was supposed to hit, so a
measureless report now says so and defers to the local check.
- `logoCandidates` can hold several entries of the same kind: the real run
returned the wordmark AND the page's og:image (a screenshot) both as `logo`.
First-match-per-kind is still right, but the pick is now marked in the output
and overridable with --logo / --logo-dark / --favicon on from-url, so a bad
pick is one flag rather than a re-run plus a follow-up `set`.
- Reworded the 400 diagnosis to name the symptom ("the server's theme schema
predates the token you set") instead of the two files to edit. Those files
are moving under Lane A right now; the symptom won't.
Also pins the merge semantics the lead flagged as the likeliest silent
regression once the server stops 400ing: set A, set B, assert A survived, and
assert an untouched token stays absent rather than becoming an explicit null.
Verified live again after the refactor (org "Smoo AI Test", restored after):
sequential sets accumulate, `--accent ''` clears just that token, logo upload
still lands a public URL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dr6czSxJFJodJ9JkYiiBSp
…in the row
Answering the lead's check: `set --primary` was already sending only the keys
with values — no 12-null padding, so the CLI never had the dashboard's bug.
But it did echo back nulls it inherited from the stored row, and SMOODEV-2822
shows the dashboard writes exactly those. A strict server rejects on the KEY,
not the value, so one dashboard save would have poisoned every later CLI write
to that org.
`merge_theme` now drops null-valued keys entirely. Safe because PUT replaces
the whole `theme_json` column: an absent key and a null one read identically,
so dropping still clears a token. Side effect worth having — one
`th branding set` heals a poisoned row instead of inheriting it.
Verified live: seeded a dashboard-shaped row
(`{primary:"#7c3aed", accent:null, primaryForeground:null}`), then
`set --accent '#47c4d7'` returned 200 and left `{accent, primary}` with the
nulls gone; `set --accent ''` dropped the key rather than nulling it.
Test pins both halves — no padding, no inherited nulls.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dr6czSxJFJodJ9JkYiiBSp
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
White-labeling a partner org meant clicking through the dashboard, and the logo step never really worked: the settings page takes a URL, so the partner's logo stays hosted on the partner's own server. There was also no scriptable path at all — no way to stand up a partner's branding from a terminal or a provisioning script.
Solution
New top-level
th branding(aliasth brand), theth crmprecedent:--logo/--logo-dark/--faviconeach take a local path or a remote URL. A remote one is fetched and re-uploaded to the org's brand assets, so what lands in the branding row is always a URL we serve.Three deliberate refusals
enablerefuses an unreadable theme. WCAG contrast on the three foreground/background pairs, 4.5:1 floor, checked before the write.--forceoverrides, loudly.from-url --enablehonours Lane A'scontrast.passes:falseand falls back to the local check when no report is present; disagreement resolves toward not shipping.vetUrldoes — http(s) only, no loopback / RFC1918 /169.254.(cloud metadata), no redirect following, 5 MB cap — and the bytes are magic-byte sniffed against the platform allowlist before upload..ico(whatlink[rel=icon]usually yields) gets a named error rather than an opaque 400.themeJsoncolumn, so a naiveset --primarywould silently wipe every other token the partner had configured.from-urlis a dry run by default — swatch table, logo candidates, contrast verdict, zero writes.--applystages (enabledstays false, previewable via?brandPreview=1);--enablegoes live.Which upload endpoint, and why
POST /organizations/:org_id/logo/upload(multipartfile+variant) — the only endpoint that puts brand images in the PUBLIC-read published-assets bucket. The presignedfiles/upload-urlflow (files.rs) writes to the private bucket, whose URLs 403 everywhere they render (pearl th-8f3591 / SMOODEV-2566), which makes it unusable for a logo.Its tradeoff is a side effect: it also stamps
organizations.<variant>. That is correct for--logo(→logo) and--favicon(→icon) — same asset, another name. There is no dark slot, and the spare columnlogoWordmarkis load-bearing (it renders in live customer proposals), so the dark upload borrowslogoand puts the column back. Markedponytail:with the upgrade path: add alogoDarkvariant toorganization-logo-upload.tsand delete the workaround.Locked
The Aurora meaning tokens (
--color-heat-0..5,--color-ai,--gradient-aurora, ok/warn/crit) encode meaning, not chrome. No flags, and a test asserts they stay absent from a merged theme.Verified live (prod, org "Smoo AI Test", fully restored afterward)
show(configured / staged / LIVE /--json) ·setapp-name + accent tokens · theme merge keeps untouched tokens ·--logofrom a local PNG ·--faviconfrom a local SVG ·--logo-darkfrom a remote URL (fetched, re-hosted, andorganizations.logoconfirmed restored,logoWordmarkuntouched) ·enablepassing 5.7:1 ·enablerefusing 1.3:1 and--forceoverriding ·disable·preview·clear --yes·clearrefusing on a non-tty · SSRF refusals for169.254.169.254andlocalhost·.icoand non-image rejections · the 400 and 404 diagnoses below.Unit-tested only (no endpoint yet):
from-urlparsing/rendering + the--enablegate, against the frozen Lane A fixture. 28 tests;cargo fmt/clippy/testgreen (smooth-cli: 478 pass).Two server-side gaps this surfaced
Both are pre-existing platform bugs, reported to the lead and Lane A, not introduced here — the CLI turns each into a diagnosis instead of a bare error:
PUT …/brandingwith any SMOODEV-1813 surface token (background,card,sidebar, …) → 400, verified live. Bothrust/api-prime/src/handlers/organization_branding.rs::ThemeOverride(deny_unknown_fields, the live path) andpackages/backend/src/routes/organization-branding.ts::ThemeOverrideOpenApi(.strict()) still carry the six Phase-1 fields, while the canonical Zod and the dashboard read path have all 13. The dashboard's own branding settings page sendsbackground, so saving a surface color from the UI is broken in prod today.PATCH …/branding→ 404. The manifest routes it, the TS app registers no PATCH handler. PUT is the only write verb, hence the read-modify-write above.from-url404s until Lane A's propose endpoint deploys; that path is coded to the frozen contract and unit-tested against it.Docs
docs/Engineering/Using-th-CLI.md(new section + cheat sheet) andCLAUDE.md(command inventory, 53 → 54 top-level commands). Changeset included.🤖 Generated with Claude Code
https://claude.ai/code/session_01Dr6czSxJFJodJ9JkYiiBSp